@colixsystems/widget-sdk 0.212.0 → 0.214.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -83,7 +83,60 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
83
83
 
84
84
  ## Status
85
85
 
86
- `v0.212.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
86
+ `v0.214.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
87
+
88
+ ### What's new in 0.214.0 (contract 1.173.0)
89
+
90
+ **Who is behind a figure — `useActiveUsers({ within, group_id, limit, offset })`.** One page of the users seen within `within` — `"online"` (default; the app open in the last two minutes), `"day"` (since midnight in the workspace's time zone), `"week"` or `"month"` (the last 7 / 30 days) — most recently seen first, as `[{ id, name, last_active_at, last_platform }]` with `total` for paging. Same gate as `useUserActivity` (`users.activity:*` plus the "View user activity" permission); pass `null` to read nothing.
91
+
92
+ **"Who joined" — `useUsers({ joined_since, sort: "newest" })`.** The admin user list narrows to users created since `joined_since` (a `YYYY-MM-DD` date read in the workspace's time zone, or an ISO instant) and lists them newest first. Join dates are admin data, so a caller with only the directory view gets the plain list.
93
+
94
+ **Reminding several — `remind(id, message, { refetch: false })`.** `useInactiveUsers`' `remind` re-reads the page after each reminder; pass `{ refetch: false }` while sending a batch and call `refetch()` once at the end.
95
+
96
+ ### What's new in 0.213.0 (contract 1.172.0)
97
+
98
+ **New component `<ConfirmDialog>` — the standard "are you sure?".** Widgets asked for confirmation each in their own way (a button that re-labels itself to be pressed twice, a hand-laid Yes / No row), so a delete looked and behaved differently from one widget to the next. `<ConfirmDialog>` is that prompt, built once as a preset of `<Modal>` and `<Button>`:
99
+
100
+ ```jsx
101
+ import { useState } from "react";
102
+ import { ConfirmDialog, Button, useToast } from "@colixsystems/widget-sdk";
103
+
104
+ function DeleteOrder({ order, onDelete }) {
105
+ const [asking, setAsking] = useState(false);
106
+ const { showToast } = useToast();
107
+ return (
108
+ <>
109
+ <Button variant="danger" title="Delete" onPress={() => setAsking(true)} />
110
+ <ConfirmDialog
111
+ visible={asking}
112
+ tone="destructive"
113
+ title="Delete this order?"
114
+ message="This cannot be undone."
115
+ confirmLabel="Delete"
116
+ onCancel={() => setAsking(false)}
117
+ onConfirm={async () => {
118
+ try {
119
+ await onDelete(order.id);
120
+ } catch (err) {
121
+ showToast({ kind: "error", message: err.message });
122
+ }
123
+ setAsking(false);
124
+ }}
125
+ />
126
+ </>
127
+ );
128
+ }
129
+ ```
130
+
131
+ Props: `visible`, `title` (the question), `message` (what confirming does; text is drawn in the theme's body style, any other node as it is), `confirmLabel` and `cancelLabel`, `tone` (`default` | `destructive`), `busy`, `onConfirm`, `onCancel`, `closeAccessibilityLabel` and `testID` (the parts take `-message`, `-cancel`, `-confirm` and `-busy`, plus the Modal's own).
132
+
133
+ - **Cancelling is the safe default.** The Cancel button, the close button, a backdrop press, Escape on web and the Android back button all fire `onCancel`, never `onConfirm`.
134
+ - **`tone="destructive"`** draws the confirm button as the danger `<Button>`, so it takes its colours from the theme's `destructive` scope (Design > Components > Destructive), and on web opens with focus on Cancel. `default` draws the primary button and opens with focus on Confirm. Focus placement is web only.
135
+ - **Busy.** Return the action's promise from `onConfirm` and the dialog stays busy until it settles: both buttons are disabled, a `<Spinner>` shows, nothing dismisses the dialog, and a second press is ignored. Pass `busy` yourself when the action runs elsewhere; the two combine. A rejected promise is **not** caught: handle the failure inside `onConfirm`, otherwise it is reported as an unhandled rejection. Either way the dialog becomes usable again.
136
+ - **It never closes itself.** Set `visible` to false from `onCancel` and once the action is done.
137
+ - **Labels follow the app's language.** Unset, the buttons read the app's shared `confirm`, `cancel` and `close` translations (the same ones `useI18n().t("cancel")` resolves), falling back to English. Prefer a `confirmLabel` that names the action: "Delete", "Remove", "Send".
138
+
139
+ It adds no theme scope: the panel follows `modal`, the actions `button`, a destructive confirm `destructive`. `<Button>` now forwards a `ref` to its pressable, which is how the dialog places focus. Additive.
87
140
 
88
141
  ### What's new in 0.212.0 (contract 1.171.0)
89
142
 
@@ -201,7 +254,7 @@ Like the `loading` scope, `alert` styles an SDK component rather than a widget's
201
254
 
202
255
  **An app's user totals for its own admins — `useUserStats({ days, group_id })`.** Active and deactivated users, new users per day (7 / 30 days) or week (90 days) with the previous period's total beside them, users in no group, empty groups, and invite figures. Totals only — never a list of people. Needs `users.read:*` and the viewer's admin user access (else `error.code === "FORBIDDEN"`); `stats.invites` is null unless the widget also declares `users.write:*` and the viewer holds it.
203
256
 
204
- **User activity — `useUserActivity({ days, group_id })` and `useInactiveUsers({ inactive_days, group_id, limit, offset })`.** For a workspace that records user activity: who is online now, active in the last day / week / month, never active, active users per day or week, the web / iOS / Android split and the active share per group — and a page of the users not seen recently, never-seen first. Needs `users.activity:*` and the viewer's "View user activity" permission (else `error.code === "FORBIDDEN"`); when the workspace does not record activity, `error.reason === "ACTIVITY_DISABLED"` — show that, not zeros. Before user activity is offered to the workspace at all, `error.reason` is `"ACTIVITY_UNAVAILABLE"`: hide the section, as for `FORBIDDEN`. `useInactiveUsers` also returns `remind(userId, { title, body })`, which notifies one of them and records when (each row carries `last_reminded_at`). Days follow the workspace's time zone (`period.time_zone`).
257
+ **User activity — `useUserActivity({ days, group_id })` and `useInactiveUsers({ inactive_days, group_id, limit, offset })`.** For a workspace that records user activity: who is online now, active in the last day / week / month, never active, active users per day or week, the web / iOS / Android split and the active share per group — and a page of the users not seen recently, never-seen first. Needs `users.activity:*` and the viewer's "View user activity" permission (else `error.code === "FORBIDDEN"`); when the workspace does not record activity, `error.reason === "ACTIVITY_DISABLED"` — show that, not zeros. Before user activity is offered to the workspace at all, `error.reason` is `"ACTIVITY_UNAVAILABLE"`: hide the section, as for `FORBIDDEN`. `useInactiveUsers` also returns `remind(userId, { title, body })`, which notifies one of them and records when (each row carries `last_reminded_at`); pass `{ refetch: false }` as a third argument when reminding several, then call `refetch()` once. Days follow the workspace's time zone (`period.time_zone`).
205
258
 
206
259
  **Linter fix.** A widget that uses `useGroups` or `useUsers` is no longer refused for a write it never makes: `StyleSheet.create(...)` and an event subscription's bare `remove()` (an `AppState` listener, say) are not user or group writes.
207
260
 
@@ -2550,6 +2603,7 @@ import { defineWidget, validateManifest, useDatastoreQuery, Text, View } from "@
2550
2603
  - `WidgetTree({ node })` — component that renders an author-authored child node through the host's renderer; used by Tabs / Card / custom containers to host arbitrary child widgets.
2551
2604
  - `Text`, `View`, `Pressable`, `Image`, `ScrollView`, `TextInput`, `FlatList`, `SectionList`, `ActivityIndicator`, `Switch`, `StyleSheet`, `Linking`, `Icon`, `DateTimePicker` — re-exported from `react-native` (the RN primitives) or implemented in the SDK (`Icon` wraps `lucide-react-native`; `DateTimePicker` wraps `@react-native-community/datetimepicker` on native and renders `<input type="date|time|datetime-local">` directly on web because the RN library has no react-native-web mapping). The web build aliases `react-native` to `react-native-web` so the RN-re-exported primitives render in the browser without any per-platform code; the exported Expo app's Metro bundler resolves the real `react-native` library. `Linking` is a static API (`Linking.openURL(url)`) — use it for external URLs, and use `useNavigation().goTo(pageId)` for internal page navigation. See https://reactnative.dev/docs/ for per-component props.
2552
2605
  - `Overlay` — the screen-level overlay (sc-6607). `<Overlay visible={!!preview} onRequestClose={() => setPreview(null)} size="full">…</Overlay>` renders its children OUTSIDE the widget's layout box on both hosts (web portals it to the document root, native uses the OS modal), so no clipping card, `ScrollView` or neighbouring widget can cut off a document/media preview, lightbox or confirm dialog — which absolute positioning inside the widget cannot achieve on either platform. Children stay in your own React tree, so state and hooks work normally. `onRequestClose` carries the backdrop press, Escape on web, and the Android back button; `size` is `sm` | `md` (default) | `lg` | `full`; the scrim, surface, radius, padding and elevation come from the workspace theme. An anchored dropdown still belongs inside your own root.
2606
+ - `ConfirmDialog` — the standard confirmation prompt, a preset of `Modal` and `Button`. `<ConfirmDialog visible={asking} tone="destructive" title="Delete this order?" message="This cannot be undone." confirmLabel="Delete" onCancel={() => setAsking(false)} onConfirm={removeOrder} />` asks before a delete or any other action that cannot be undone. Cancel, the close button, the backdrop, Escape and the Android back button fire `onCancel`; a promise returned from `onConfirm` keeps it busy (buttons disabled, a second press ignored) until it settles. Props: `visible`, `title`, `message`, `confirmLabel`, `cancelLabel`, `tone` (`default` | `destructive`), `busy`, `onConfirm`, `onCancel`, `closeAccessibilityLabel`, `testID`. Use it instead of building a confirmation from `Modal` yourself.
2553
2607
  - `Modal` — the standard dialog, built on `Overlay`. `<Modal visible={open} onRequestClose={() => setOpen(false)} title="Edit order" footer={<Button title="Save" onPress={save} />}>…</Modal>` draws a title, a close button, a body that scrolls when it is long and a footer action row laid out below it. Its background, corner radius, padding, scrim, shadow and title colour come from the theme's `modal` scope (Design > Components > Dialogs), so every dialog in an app matches. Props: `visible`, `onRequestClose`, `dismissible`, `title`, `footer`, `presentation` (`dialog` | `sheet`), `size` (`sm` | `md` | `lg` | `full`), `dismissOnBackdropPress`, `role`, `initialFocusRef`, `closeAccessibilityLabel`, `accessibilityLabel`, `testID`. Use it for every dialog instead of hand-building a header and an action row inside `Overlay`.
2554
2608
  - `RichText` — the formatted-content renderer (sc-6970). `<RichText value={post.body} />` parses the markdown subset (`**bold**`, `*italic*` / `_italic_`, `` `code` ``, `#`/`##`/`###` headings, `-`/`*` bullets, `1.` ordered items, one-line `![alt](src "large")` images) and renders it with SDK primitives, so formatted content reads the same in the web Player and the exported Expo app. `value` is markdown, NOT HTML — widgets have no `dangerouslySetInnerHTML` on either host, so tags in a content string render as literal tag soup; stored text that still holds legacy HTML is stripped to plain text rather than shown as markup. Props: `value`, `renderImage` (`({ src, alt, size }) => node` — supply it when images are filestore ids, because resolving one needs the scopes your own widget holds; without it only absolute `http(s)` URLs render), `style`, `testID`. Colour, type scale and leading come from the workspace theme — never re-style them.
2555
2609
  - `MarkdownInput` — the authoring half of `<RichText>` (sc-6970). `<MarkdownInput value={draft} onChange={setDraft} />` is a multi-line field, a toolbar whose buttons wrap the current SELECTION in markdown markers (bold, italic, code, H2, H3, bullets, numbered list, each toggling back off), and a live `<RichText>` preview of the result underneath — the way to let an app USER write formatted text, instead of a bare `TextInput` plus hand-rolled buttons that can only splice literal tags into the value. Props: `value`, `onChange(next)`, `placeholder`, `previewLabel` (default `Preview`), `showPreview` (default true), `renderImage` (passed through to the preview), `minHeight` (150) and `maxHeight` (280) so a long draft scrolls inside its box, `accessibilityLabel`, `style`, `testID`.
@@ -100,8 +100,9 @@ export function makeButton(rn) {
100
100
  * @param {boolean} [props.hostThemed] — `fields` already carry the theme
101
101
  * (a built-in the host folded); skip the second fold, which would add the
102
102
  * Button widget's own scopes to a form submit or a Link action.
103
+ * @param {object} ref — forwarded to the pressable, so a caller can focus it.
103
104
  */
104
- function Button({
105
+ const Button = React.forwardRef(function Button({
105
106
  title,
106
107
  variant = "primary",
107
108
  icon,
@@ -114,7 +115,7 @@ export function makeButton(rn) {
114
115
  hostThemed = false,
115
116
  accessibilityLabel,
116
117
  testID,
117
- }) {
118
+ }, ref) {
118
119
  const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
119
120
  const { colors, spacing, radii, typography } = theme;
120
121
  const f = hostThemed
@@ -192,6 +193,7 @@ export function makeButton(rn) {
192
193
  return React.createElement(
193
194
  Pressable,
194
195
  {
196
+ ref,
195
197
  onPress,
196
198
  disabled,
197
199
  accessibilityRole: "button",
@@ -202,7 +204,7 @@ export function makeButton(rn) {
202
204
  },
203
205
  children,
204
206
  );
205
- }
207
+ });
206
208
 
207
209
  Button.displayName = "Button";
208
210
  return Button;
@@ -0,0 +1,158 @@
1
+ // The ONE implementation behind the `<ConfirmDialog>` SDK component: a preset
2
+ // of `<Modal>` holding a message, a Cancel and a Confirm `<Button>`.
3
+ //
4
+ // Bound per host by confirm-dialog.js / confirm-dialog.native.js, which differ
5
+ // only in where the primitives come from, so the two hosts cannot drift.
6
+
7
+ import React from "react";
8
+ import { useHostSharedText, useHostTheme } from "./hooks.js";
9
+ import { DEFAULT_THEME_TOKENS } from "./_theme-tokens.js";
10
+ import { textRoleStyle } from "./text-roles.js";
11
+
12
+ function isThenable(value) {
13
+ return value != null && typeof value.then === "function";
14
+ }
15
+
16
+ function isText(value) {
17
+ return typeof value === "string" || typeof value === "number";
18
+ }
19
+
20
+ /**
21
+ * Bind the shared confirmation to one platform's primitives and to that
22
+ * host's `<Modal>`, `<Button>` and `<Spinner>`.
23
+ *
24
+ * @param {{Text: any}} rn
25
+ * @param {any} Modal — the host's bound `<Modal>`.
26
+ * @param {any} Button — the host's bound `<Button>`.
27
+ * @param {any} Spinner — the host's bound `<Spinner>`.
28
+ */
29
+ export function makeConfirmDialog(rn, Modal, Button, Spinner) {
30
+ const { Text } = rn;
31
+
32
+ // Rendered inside the Modal, so it reads the theme the Modal re-inks.
33
+ function Message({ testID, children }) {
34
+ const theme = useHostTheme() || DEFAULT_THEME_TOKENS;
35
+ return React.createElement(
36
+ Text,
37
+ { testID, style: textRoleStyle("body", theme.typography, theme.colors) },
38
+ children,
39
+ );
40
+ }
41
+
42
+ /**
43
+ * The standard "are you sure?" prompt. Cancelling is the safe default: the
44
+ * close button, the backdrop, Escape (web) and the Android back button all
45
+ * fire `onCancel`, never `onConfirm`.
46
+ *
47
+ * @param {object} props
48
+ * @param {boolean} [props.visible] — nothing is mounted while false.
49
+ * @param {string} props.title — the question, e.g. "Delete this order?".
50
+ * @param {any} [props.message] — what confirming does; text is drawn in the
51
+ * theme's body style, any other node is rendered as it is.
52
+ * @param {string} [props.confirmLabel] — default the app's "Confirm".
53
+ * @param {string} [props.cancelLabel] — default the app's "Cancel".
54
+ * @param {"default"|"destructive"} [props.tone] — `destructive` draws the
55
+ * confirm button from the theme's Destructive colours and, on web, opens
56
+ * with focus on Cancel; `default` opens with focus on Confirm.
57
+ * @param {boolean} [props.busy] — the confirmed action is running: both
58
+ * buttons are disabled, a loading indicator shows, nothing closes it.
59
+ * @param {() => void|Promise<any>} [props.onConfirm] — a returned promise
60
+ * keeps the dialog busy until it settles. A rejection is not caught.
61
+ * @param {() => void} [props.onCancel]
62
+ * @param {string} [props.closeAccessibilityLabel] — default the app's "Close".
63
+ * @param {string} [props.testID] — on the panel; the parts take `-message`,
64
+ * `-cancel`, `-confirm` and `-busy`, plus the Modal's own.
65
+ */
66
+ function ConfirmDialog({
67
+ visible = false,
68
+ title,
69
+ message,
70
+ confirmLabel,
71
+ cancelLabel,
72
+ tone = "default",
73
+ busy = false,
74
+ onConfirm,
75
+ onCancel,
76
+ closeAccessibilityLabel,
77
+ testID,
78
+ }) {
79
+ const sharedConfirm = useHostSharedText("confirm", "Confirm");
80
+ const sharedCancel = useHostSharedText("cancel", "Cancel");
81
+ const sharedClose = useHostSharedText("close", "Close");
82
+ const [pending, setPending] = React.useState(false);
83
+ // Set before the re-render, so a second press in the same frame is ignored.
84
+ const inFlight = React.useRef(false);
85
+ const cancelRef = React.useRef(null);
86
+ const confirmRef = React.useRef(null);
87
+
88
+ const working = busy || pending;
89
+ const destructive = tone === "destructive";
90
+ const part = (name) => (testID ? `${testID}-${name}` : undefined);
91
+
92
+ const confirm = () => {
93
+ if (working || inFlight.current || typeof onConfirm !== "function") return;
94
+ const result = onConfirm();
95
+ if (!isThenable(result)) return;
96
+ inFlight.current = true;
97
+ setPending(true);
98
+ const settle = () => {
99
+ inFlight.current = false;
100
+ setPending(false);
101
+ };
102
+ result.then(settle, (error) => {
103
+ settle();
104
+ // Rethrown so a failure the caller did not handle is still reported.
105
+ throw error;
106
+ });
107
+ };
108
+
109
+ const cancel = () => {
110
+ if (working || inFlight.current) return;
111
+ if (typeof onCancel === "function") onCancel();
112
+ };
113
+
114
+ const footer = React.createElement(
115
+ React.Fragment,
116
+ null,
117
+ working ? React.createElement(Spinner, { testID: part("busy") }) : null,
118
+ React.createElement(Button, {
119
+ ref: cancelRef,
120
+ variant: "secondary",
121
+ title: cancelLabel ?? sharedCancel,
122
+ onPress: cancel,
123
+ disabled: working,
124
+ testID: part("cancel"),
125
+ }),
126
+ React.createElement(Button, {
127
+ ref: confirmRef,
128
+ variant: destructive ? "danger" : "primary",
129
+ title: confirmLabel ?? sharedConfirm,
130
+ onPress: confirm,
131
+ disabled: working,
132
+ testID: part("confirm"),
133
+ }),
134
+ );
135
+
136
+ return React.createElement(
137
+ Modal,
138
+ {
139
+ visible,
140
+ title,
141
+ footer,
142
+ role: "alertdialog",
143
+ size: "sm",
144
+ dismissible: !working,
145
+ onRequestClose: cancel,
146
+ initialFocusRef: destructive ? cancelRef : confirmRef,
147
+ closeAccessibilityLabel: closeAccessibilityLabel ?? sharedClose,
148
+ testID,
149
+ },
150
+ isText(message)
151
+ ? React.createElement(Message, { testID: part("message") }, message)
152
+ : message,
153
+ );
154
+ }
155
+
156
+ ConfirmDialog.displayName = "ConfirmDialog";
157
+ return ConfirmDialog;
158
+ }
@@ -0,0 +1,11 @@
1
+ // `<ConfirmDialog>` (web). Binds ./confirm-dialog-view.js to react-native-web
2
+ // and the web `<Modal>`, `<Button>` and `<Spinner>`; see ./primitives.js for
3
+ // why not the bare specifier.
4
+
5
+ import { Text } from "react-native-web";
6
+ import { makeConfirmDialog } from "./confirm-dialog-view.js";
7
+ import { Modal } from "./modal.js";
8
+ import { Button } from "./button.js";
9
+ import { Spinner } from "./loading.js";
10
+
11
+ export const ConfirmDialog = makeConfirmDialog({ Text }, Modal, Button, Spinner);
@@ -0,0 +1,11 @@
1
+ // `<ConfirmDialog>` (native). Binds ./confirm-dialog-view.js to React Native's
2
+ // own primitives and the native `<Modal>`, `<Button>` and `<Spinner>`; only the
3
+ // imports differ from ./confirm-dialog.js.
4
+
5
+ import { Text } from "react-native";
6
+ import { makeConfirmDialog } from "./confirm-dialog-view.js";
7
+ import { Modal } from "./modal.native.js";
8
+ import { Button } from "./button.native.js";
9
+ import { Spinner } from "./loading.native.js";
10
+
11
+ export const ConfirmDialog = makeConfirmDialog({ Text }, Modal, Button, Spinner);
package/dist/contract.cjs CHANGED
@@ -1960,7 +1960,9 @@ const HOOKS = [
1960
1960
  "Returns { users, loading, error, refetch, invite, deactivate, " +
1961
1961
  "reactivate, remove, sendPasswordReset, update }. " +
1962
1962
  "list returns the { data, meta } envelope verbatim — the hook unwraps " +
1963
- "res.data; rows are snake_case (is_active, …). Reads need users.read:* " +
1963
+ "res.data; rows are snake_case (is_active, created_at, …). For an admin, " +
1964
+ "query { joined_since, sort: \"newest\" } lists who joined since a date " +
1965
+ "(read in the workspace's time zone), newest first. Reads need users.read:* " +
1964
1966
  "scope; edit-style mutations (invite/deactivate/reactivate/" +
1965
1967
  "sendPasswordReset/update) need users.write:*, and the destructive remove() " +
1966
1968
  "additionally needs users.delete:* (SC-902). The `invite` call accepts " +
@@ -2082,6 +2084,30 @@ const HOOKS = [
2082
2084
  requiredContextSlice: ["directory.users"],
2083
2085
  scopes: ["users.activity:*"],
2084
2086
  },
2087
+ // sc-11066 — who was seen in a window, behind users.activity.
2088
+ {
2089
+ name: "useActiveUsers",
2090
+ signature: "useActiveUsers({ within, group_id, limit, offset } | null)",
2091
+ optionalArgs: ["within", "group_id", "limit", "offset"],
2092
+ description:
2093
+ "One page of users seen within `within`: \"online\" (default; the app " +
2094
+ "open in the last two minutes), \"day\" (since midnight in the " +
2095
+ "workspace's time zone), \"week\" or \"month\" (the last 7 / 30 days), " +
2096
+ "most recently seen first: `users` = [{ id, name, last_active_at, " +
2097
+ "last_platform }], `total` for paging. Use it to show WHO is behind an " +
2098
+ "online or active figure. Same gate and errors as useUserActivity; null " +
2099
+ "reads nothing.",
2100
+ returnShape: {
2101
+ users: "Array<{ id, name, last_active_at, last_platform }>",
2102
+ total: "number | null",
2103
+ loading: "boolean",
2104
+ fetching: "boolean",
2105
+ error: "DirectoryError | null",
2106
+ refetch: "() => Promise<void>",
2107
+ },
2108
+ requiredContextSlice: ["directory.users"],
2109
+ scopes: ["users.activity:*"],
2110
+ },
2085
2111
  {
2086
2112
  name: "useInactiveUsers",
2087
2113
  signature: "useInactiveUsers({ inactive_days, group_id, limit, offset } | null)",
@@ -2091,7 +2117,8 @@ const HOOKS = [
2091
2117
  "never-seen first, then least recently seen: `users` = [{ id, name, " +
2092
2118
  "last_active_at, last_reminded_at }] (last_active_at null = never seen " +
2093
2119
  "since recording began), `total` for paging. `remind(id, { title, body })` " +
2094
- "notifies one of them, records when (last_reminded_at) and re-reads. " +
2120
+ "notifies one of them, records when (last_reminded_at) and re-reads; " +
2121
+ "pass `{ refetch: false }` third to skip the re-read in a batch. " +
2095
2122
  "Same gate and errors as useUserActivity; null reads nothing.",
2096
2123
  returnShape: {
2097
2124
  users: "Array<{ id, name, last_active_at, last_reminded_at }>",
@@ -2100,7 +2127,7 @@ const HOOKS = [
2100
2127
  fetching: "boolean",
2101
2128
  error: "DirectoryError | null",
2102
2129
  refetch: "() => Promise<void>",
2103
- remind: "(id, { title, body }) => Promise<void>",
2130
+ remind: "(id, { title, body }, { refetch? }) => Promise<void>",
2104
2131
  },
2105
2132
  requiredContextSlice: ["directory.users"],
2106
2133
  scopes: ["users.activity:*"],
@@ -2920,6 +2947,13 @@ const PRIMITIVES = [
2920
2947
  rnComponent: null,
2921
2948
  docsUrl: null,
2922
2949
  },
2950
+ {
2951
+ name: "ConfirmDialog",
2952
+ description:
2953
+ 'Standard confirmation prompt, a preset of `<Modal>`. `<ConfirmDialog visible={!!pending} tone="destructive" title="Delete this order?" message="This cannot be undone." confirmLabel={t("delete", "Delete")} onCancel={() => setPending(null)} onConfirm={async () => { await remove(pending.id); setPending(null); }} />`. THE way to ask "are you sure?" before a delete or any other action that cannot be undone, so every confirmation in the app has the same wording slots, button order and colours. Never hand-build one from a `<Modal>` or an `<Overlay>` with your own two buttons, never arm a button to be pressed twice, and never use `Alert.alert` or `window.confirm`. Props: `visible` (boolean), `title` (string, the question), `message` (what confirming does; text or a node), `confirmLabel` and `cancelLabel` (default the app\'s translated "Confirm" and "Cancel" — name the action: "Delete", "Remove", "Send"), `tone` ("default" | "destructive"; destructive draws the confirm button from the theme\'s Destructive colours and opens with focus on Cancel), `busy` (boolean; disables both buttons, shows a `<Spinner>` and keeps the dialog open), `onConfirm` (return the promise of the action and the dialog stays busy until it settles, with a second press ignored; a rejection is NOT caught, so catch a failure yourself and report it), `onCancel` (fired by the Cancel button, the close button, the backdrop press, the Escape key on web and the Android back button — cancelling is always the safe default), `closeAccessibilityLabel` and `testID`. The dialog never closes itself: set `visible` false from `onCancel` and after the action. Its panel follows the theme (Design > Components > Dialogs) and its buttons the Buttons and Destructive settings — do not re-style it.',
2954
+ rnComponent: null,
2955
+ docsUrl: null,
2956
+ },
2923
2957
  // sc-8104 — the in-app emoji picker. Typed emoji always worked; what this
2924
2958
  // replaces is being forced out to the OS keyboard to find one.
2925
2959
  {
@@ -5628,7 +5662,16 @@ const CONTRACT = deepFreeze({
5628
5662
  // switch, so an existing `<Switch>` with no colour props is repainted:
5629
5663
  // its tracks follow the theme on both hosts and its "on" thumb is white
5630
5664
  // on the web. A caller's `trackColor` / `thumbColor` still win.
5631
- version: "1.171.0",
5665
+ // 1.172.0: additive (sc-10925) — the `ConfirmDialog` primitive: the standard
5666
+ // confirmation, a preset of `Modal` and `Button` (title, message, confirm
5667
+ // and cancel labels, `tone` "default" | "destructive", `busy`). No new
5668
+ // theme scope: the panel reads `modal`, the actions `button` and
5669
+ // `destructive`. `Button` forwards a ref to its pressable.
5670
+ // 1.173.0: additive (sc-11066) — `useActiveUsers(query)` lists who was
5671
+ // seen online / today / in 7 or 30 days through the new
5672
+ // `ctx.directory.users.active`; `useUsers` takes `joined_since` and
5673
+ // `sort: "newest"` for an admin's "who joined" list.
5674
+ version: "1.173.0",
5632
5675
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5633
5676
  hooks: HOOKS,
5634
5677
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -1960,7 +1960,9 @@ const HOOKS = [
1960
1960
  "Returns { users, loading, error, refetch, invite, deactivate, " +
1961
1961
  "reactivate, remove, sendPasswordReset, update }. " +
1962
1962
  "list returns the { data, meta } envelope verbatim — the hook unwraps " +
1963
- "res.data; rows are snake_case (is_active, …). Reads need users.read:* " +
1963
+ "res.data; rows are snake_case (is_active, created_at, …). For an admin, " +
1964
+ "query { joined_since, sort: \"newest\" } lists who joined since a date " +
1965
+ "(read in the workspace's time zone), newest first. Reads need users.read:* " +
1964
1966
  "scope; edit-style mutations (invite/deactivate/reactivate/" +
1965
1967
  "sendPasswordReset/update) need users.write:*, and the destructive remove() " +
1966
1968
  "additionally needs users.delete:* (SC-902). The `invite` call accepts " +
@@ -2082,6 +2084,30 @@ const HOOKS = [
2082
2084
  requiredContextSlice: ["directory.users"],
2083
2085
  scopes: ["users.activity:*"],
2084
2086
  },
2087
+ // sc-11066 — who was seen in a window, behind users.activity.
2088
+ {
2089
+ name: "useActiveUsers",
2090
+ signature: "useActiveUsers({ within, group_id, limit, offset } | null)",
2091
+ optionalArgs: ["within", "group_id", "limit", "offset"],
2092
+ description:
2093
+ "One page of users seen within `within`: \"online\" (default; the app " +
2094
+ "open in the last two minutes), \"day\" (since midnight in the " +
2095
+ "workspace's time zone), \"week\" or \"month\" (the last 7 / 30 days), " +
2096
+ "most recently seen first: `users` = [{ id, name, last_active_at, " +
2097
+ "last_platform }], `total` for paging. Use it to show WHO is behind an " +
2098
+ "online or active figure. Same gate and errors as useUserActivity; null " +
2099
+ "reads nothing.",
2100
+ returnShape: {
2101
+ users: "Array<{ id, name, last_active_at, last_platform }>",
2102
+ total: "number | null",
2103
+ loading: "boolean",
2104
+ fetching: "boolean",
2105
+ error: "DirectoryError | null",
2106
+ refetch: "() => Promise<void>",
2107
+ },
2108
+ requiredContextSlice: ["directory.users"],
2109
+ scopes: ["users.activity:*"],
2110
+ },
2085
2111
  {
2086
2112
  name: "useInactiveUsers",
2087
2113
  signature: "useInactiveUsers({ inactive_days, group_id, limit, offset } | null)",
@@ -2091,7 +2117,8 @@ const HOOKS = [
2091
2117
  "never-seen first, then least recently seen: `users` = [{ id, name, " +
2092
2118
  "last_active_at, last_reminded_at }] (last_active_at null = never seen " +
2093
2119
  "since recording began), `total` for paging. `remind(id, { title, body })` " +
2094
- "notifies one of them, records when (last_reminded_at) and re-reads. " +
2120
+ "notifies one of them, records when (last_reminded_at) and re-reads; " +
2121
+ "pass `{ refetch: false }` third to skip the re-read in a batch. " +
2095
2122
  "Same gate and errors as useUserActivity; null reads nothing.",
2096
2123
  returnShape: {
2097
2124
  users: "Array<{ id, name, last_active_at, last_reminded_at }>",
@@ -2100,7 +2127,7 @@ const HOOKS = [
2100
2127
  fetching: "boolean",
2101
2128
  error: "DirectoryError | null",
2102
2129
  refetch: "() => Promise<void>",
2103
- remind: "(id, { title, body }) => Promise<void>",
2130
+ remind: "(id, { title, body }, { refetch? }) => Promise<void>",
2104
2131
  },
2105
2132
  requiredContextSlice: ["directory.users"],
2106
2133
  scopes: ["users.activity:*"],
@@ -2920,6 +2947,13 @@ const PRIMITIVES = [
2920
2947
  rnComponent: null,
2921
2948
  docsUrl: null,
2922
2949
  },
2950
+ {
2951
+ name: "ConfirmDialog",
2952
+ description:
2953
+ 'Standard confirmation prompt, a preset of `<Modal>`. `<ConfirmDialog visible={!!pending} tone="destructive" title="Delete this order?" message="This cannot be undone." confirmLabel={t("delete", "Delete")} onCancel={() => setPending(null)} onConfirm={async () => { await remove(pending.id); setPending(null); }} />`. THE way to ask "are you sure?" before a delete or any other action that cannot be undone, so every confirmation in the app has the same wording slots, button order and colours. Never hand-build one from a `<Modal>` or an `<Overlay>` with your own two buttons, never arm a button to be pressed twice, and never use `Alert.alert` or `window.confirm`. Props: `visible` (boolean), `title` (string, the question), `message` (what confirming does; text or a node), `confirmLabel` and `cancelLabel` (default the app\'s translated "Confirm" and "Cancel" — name the action: "Delete", "Remove", "Send"), `tone` ("default" | "destructive"; destructive draws the confirm button from the theme\'s Destructive colours and opens with focus on Cancel), `busy` (boolean; disables both buttons, shows a `<Spinner>` and keeps the dialog open), `onConfirm` (return the promise of the action and the dialog stays busy until it settles, with a second press ignored; a rejection is NOT caught, so catch a failure yourself and report it), `onCancel` (fired by the Cancel button, the close button, the backdrop press, the Escape key on web and the Android back button — cancelling is always the safe default), `closeAccessibilityLabel` and `testID`. The dialog never closes itself: set `visible` false from `onCancel` and after the action. Its panel follows the theme (Design > Components > Dialogs) and its buttons the Buttons and Destructive settings — do not re-style it.',
2954
+ rnComponent: null,
2955
+ docsUrl: null,
2956
+ },
2923
2957
  // sc-8104 — the in-app emoji picker. Typed emoji always worked; what this
2924
2958
  // replaces is being forced out to the OS keyboard to find one.
2925
2959
  {
@@ -5624,7 +5658,16 @@ const CONTRACT = deepFreeze({
5624
5658
  // switch, so an existing `<Switch>` with no colour props is repainted:
5625
5659
  // its tracks follow the theme on both hosts and its "on" thumb is white
5626
5660
  // on the web. A caller's `trackColor` / `thumbColor` still win.
5627
- version: "1.171.0",
5661
+ // 1.172.0: additive (sc-10925) — the `ConfirmDialog` primitive: the standard
5662
+ // confirmation, a preset of `Modal` and `Button` (title, message, confirm
5663
+ // and cancel labels, `tone` "default" | "destructive", `busy`). No new
5664
+ // theme scope: the panel reads `modal`, the actions `button` and
5665
+ // `destructive`. `Button` forwards a ref to its pressable.
5666
+ // 1.173.0: additive (sc-11066) — `useActiveUsers(query)` lists who was
5667
+ // seen online / today / in 7 or 30 days through the new
5668
+ // `ctx.directory.users.active`; `useUsers` takes `joined_since` and
5669
+ // `sort: "newest"` for an admin's "who joined" list.
5670
+ version: "1.173.0",
5628
5671
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5629
5672
  hooks: HOOKS,
5630
5673
  primitives: PRIMITIVES,
package/dist/index.d.ts CHANGED
@@ -1107,6 +1107,21 @@ export function useUserActivity(query?: { days?: 7 | 30 | 90; group_id?: string
1107
1107
  refetch(): Promise<void>;
1108
1108
  };
1109
1109
 
1110
+ /** Users seen within `within` (default "online"), most recently seen first. */
1111
+ export function useActiveUsers(query?: {
1112
+ within?: "online" | "day" | "week" | "month";
1113
+ group_id?: string;
1114
+ limit?: number;
1115
+ offset?: number;
1116
+ } | null): {
1117
+ users: Array<{ id: string; name: string; last_active_at: string; last_platform: "web" | "ios" | "android" | "unknown" }>;
1118
+ total: number | null;
1119
+ loading: boolean;
1120
+ fetching: boolean;
1121
+ error: Error | null;
1122
+ refetch(): Promise<void>;
1123
+ };
1124
+
1110
1125
  /** Users not seen for `inactive_days` (default 30), never-seen first. */
1111
1126
  export function useInactiveUsers(query?: {
1112
1127
  inactive_days?: number;
@@ -1121,7 +1136,8 @@ export function useInactiveUsers(query?: {
1121
1136
  error: Error | null;
1122
1137
  refetch(): Promise<void>;
1123
1138
  /** Notify one user, record when, then re-read the page. */
1124
- remind(id: string, message: { title: string; body: string }): Promise<void>;
1139
+ /** `{ refetch: false }` skips re-reading the page, for a batch that reads once. */
1140
+ remind(id: string, message: { title: string; body: string }, options?: { refetch?: boolean }): Promise<void>;
1125
1141
  };
1126
1142
 
1127
1143
  /** One record's online members, as `useOnlineMembers` reports them. */
@@ -2743,6 +2759,12 @@ export interface UsersQuery {
2743
2759
  is_active?: boolean;
2744
2760
  /** Restrict to members of one app-user group — the `id` of a `useGroups()` row. */
2745
2761
  group_id?: string;
2762
+ /** Admin view only: users created since this date (workspace time zone) or instant. */
2763
+ joined_since?: string;
2764
+ /** Admin view only: "newest" lists the most recently created first. */
2765
+ sort?: "name" | "newest";
2766
+ /** "real" leaves out preview and store-review accounts; an in-app admin always gets "real". */
2767
+ audience?: "all" | "real" | "preview";
2746
2768
  limit?: number;
2747
2769
  offset?: number;
2748
2770
  }
@@ -3360,6 +3382,70 @@ export const Modal: (props: {
3360
3382
  children?: ReactNode;
3361
3383
  }) => any;
3362
3384
 
3385
+ export type ConfirmDialogTone = "default" | "destructive";
3386
+
3387
+ /**
3388
+ * The standard confirmation prompt: a preset of `<Modal>` with a message, a
3389
+ * Cancel and a Confirm `<Button>`. Use it before a delete or any other action
3390
+ * that cannot be undone, so every confirmation in the app looks and behaves
3391
+ * the same. Cancelling is the safe default: the Cancel button, the close
3392
+ * button, the backdrop, Escape (web) and the Android back button all fire
3393
+ * `onCancel`, never `onConfirm`.
3394
+ *
3395
+ * It adds no theme scope: the panel follows `modal`, the actions `button`,
3396
+ * and a destructive confirm the `destructive` scope. It never closes itself;
3397
+ * set `visible` false from `onCancel` and once the action is done.
3398
+ *
3399
+ * @example
3400
+ * <ConfirmDialog
3401
+ * visible={!!pending}
3402
+ * tone="destructive"
3403
+ * title="Delete this order?"
3404
+ * message="This cannot be undone."
3405
+ * confirmLabel="Delete"
3406
+ * onCancel={() => setPending(null)}
3407
+ * onConfirm={async () => {
3408
+ * await remove(pending.id);
3409
+ * setPending(null);
3410
+ * }}
3411
+ * />
3412
+ */
3413
+ export const ConfirmDialog: (props: {
3414
+ /** Nothing renders (and nothing is mounted) while this is false. */
3415
+ visible?: boolean;
3416
+ /** The question, e.g. "Delete this order?". */
3417
+ title: string;
3418
+ /** What confirming does. Text is drawn in the theme's body style. */
3419
+ message?: ReactNode;
3420
+ /** Defaults to the app's translated "Confirm". Name the action instead. */
3421
+ confirmLabel?: string;
3422
+ /** Defaults to the app's translated "Cancel". */
3423
+ cancelLabel?: string;
3424
+ /**
3425
+ * `destructive` draws the confirm button from the theme's Destructive
3426
+ * colours and, on web, opens with focus on Cancel. `default` opens with
3427
+ * focus on Confirm. Focus placement is web only.
3428
+ */
3429
+ tone?: ConfirmDialogTone;
3430
+ /**
3431
+ * The confirmed action is running: both buttons are disabled, a loading
3432
+ * indicator shows, and the backdrop, Escape and Android back do nothing.
3433
+ */
3434
+ busy?: boolean;
3435
+ /**
3436
+ * Return the action's promise and the dialog stays busy until it settles,
3437
+ * ignoring a second press. A rejection is not caught: handle the failure
3438
+ * here, or it is reported as an unhandled rejection.
3439
+ */
3440
+ onConfirm?: () => void | Promise<unknown>;
3441
+ /** The Cancel and close buttons, backdrop, Escape (web) and Android back. */
3442
+ onCancel?: () => void;
3443
+ /** Label for the close button. Defaults to the app's translated "Close". */
3444
+ closeAccessibilityLabel?: string;
3445
+ /** On the panel; the parts take `-message`, `-cancel`, `-confirm` and `-busy`. */
3446
+ testID?: string;
3447
+ }) => any;
3448
+
3363
3449
  /** Per-list row overrides; unset values follow Theme Settings > Lists. */
3364
3450
  export interface ListRowTokens {
3365
3451
  rowPadding?: number;
@@ -3616,6 +3702,8 @@ export const Button: (props: {
3616
3702
  labelStyle?: any;
3617
3703
  accessibilityLabel?: string;
3618
3704
  testID?: string;
3705
+ /** Forwarded to the pressable, so you can focus the button. */
3706
+ ref?: { current: any } | ((node: any) => void);
3619
3707
  }) => any;
3620
3708
 
3621
3709
  /**
package/dist/index.js CHANGED
@@ -127,6 +127,7 @@ export { useDatastoreFigure } from "./datastore-figure.js";
127
127
  export {
128
128
  useUserStats,
129
129
  useUserActivity,
130
+ useActiveUsers,
130
131
  useInactiveUsers,
131
132
  } from "./stats-hooks.js";
132
133
  // sc-11025 — one source, bound to how each platform says the app is in front.
@@ -282,6 +283,8 @@ export { Spinner, Skeleton } from "./loading.js";
282
283
  export { Banner } from "./banner.js";
283
284
  // The standard dialog, on `<Overlay>`; follows the theme's `modal` scope.
284
285
  export { Modal } from "./modal.js";
286
+ // The standard confirmation, a preset of `<Modal>` and `<Button>`.
287
+ export { ConfirmDialog } from "./confirm-dialog.js";
285
288
  // The semantic text tiers, shared with the built-in widgets.
286
289
  export {
287
290
  textRoleStyle,
@@ -125,6 +125,7 @@ export { useDatastoreFigure } from "./datastore-figure.js";
125
125
  export {
126
126
  useUserStats,
127
127
  useUserActivity,
128
+ useActiveUsers,
128
129
  useInactiveUsers,
129
130
  } from "./stats-hooks.js";
130
131
  // sc-11025 — see the note on the web mirror in ./index.js.
@@ -275,6 +276,8 @@ export { Spinner, Skeleton } from "./loading.native.js";
275
276
  export { Banner } from "./banner.native.js";
276
277
  // The standard dialog, on `<Overlay>`; follows the theme's `modal` scope.
277
278
  export { Modal } from "./modal.native.js";
279
+ // The standard confirmation, a preset of `<Modal>` and `<Button>`.
280
+ export { ConfirmDialog } from "./confirm-dialog.native.js";
278
281
  // The semantic text tiers, shared with the built-in widgets.
279
282
  export {
280
283
  textRoleStyle,
@@ -108,13 +108,32 @@ export function useUserActivity(query) {
108
108
  return { ...state, activity: data };
109
109
  }
110
110
 
111
+ /**
112
+ * sc-11066 — one page of users seen within `within` ("online" — the last two
113
+ * minutes — "day", "week" or "month"), most recently seen first. `query`:
114
+ * `{ within?, group_id?, limit?, offset? }`. Returns `{ users, total, loading,
115
+ * fetching, error, refetch }`; each user is `{ id, name, last_active_at,
116
+ * last_platform }`. Same gate as useUserActivity; null reads nothing.
117
+ */
118
+ export function useActiveUsers(query) {
119
+ const users = useUsersNamespace("useActiveUsers", "active");
120
+ const key = query === null ? null : serialize(query);
121
+ const { data, ...state } = useKeyedRead(() => users.active(query), "active-users", key, toDirectoryError);
122
+ return {
123
+ ...state,
124
+ users: data && Array.isArray(data.data) ? data.data : [],
125
+ total: data && data.meta && Number.isFinite(data.meta.total) ? data.meta.total : null,
126
+ };
127
+ }
128
+
111
129
  /**
112
130
  * sc-10691 — one page of users not seen for `inactive_days` (default 30),
113
131
  * never-seen first. `query`: `{ inactive_days?, group_id?, limit?, offset? }`.
114
132
  * Returns `{ users, total, loading, fetching, error, refetch, remind }`; each
115
133
  * user is `{ id, name, last_active_at, last_reminded_at }` (last_active_at
116
134
  * null when never seen). `remind(id, { title, body })` notifies one and
117
- * re-reads the page. Same gate as useUserActivity.
135
+ * re-reads the page; `{ refetch: false }` as a third argument skips the
136
+ * re-read, so a batch reads once at the end. Same gate as useUserActivity.
118
137
  */
119
138
  export function useInactiveUsers(query) {
120
139
  const users = useUsersNamespace("useInactiveUsers", "inactive");
@@ -122,7 +141,7 @@ export function useInactiveUsers(query) {
122
141
  const { data, ...state } = useKeyedRead(() => users.inactive(query), "inactive", key, toDirectoryError);
123
142
  const { refetch } = state;
124
143
  const remind = useCallback(
125
- async (id, message) => {
144
+ async (id, message, { refetch: reread = true } = {}) => {
126
145
  if (typeof users.remind !== "function") {
127
146
  throw new Error("useInactiveUsers: host did not inject ctx.directory.users.remind");
128
147
  }
@@ -131,7 +150,7 @@ export function useInactiveUsers(query) {
131
150
  } catch (err) {
132
151
  throw toDirectoryError(err);
133
152
  }
134
- await refetch();
153
+ if (reread) await refetch();
135
154
  },
136
155
  [users, refetch],
137
156
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.212.0",
3
+ "version": "0.214.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "homepage": "https://github.com/Colix-AB/AppStudio",
6
6
  "type": "module",
package/src/contract.js CHANGED
@@ -1960,7 +1960,9 @@ const HOOKS = [
1960
1960
  "Returns { users, loading, error, refetch, invite, deactivate, " +
1961
1961
  "reactivate, remove, sendPasswordReset, update }. " +
1962
1962
  "list returns the { data, meta } envelope verbatim — the hook unwraps " +
1963
- "res.data; rows are snake_case (is_active, …). Reads need users.read:* " +
1963
+ "res.data; rows are snake_case (is_active, created_at, …). For an admin, " +
1964
+ "query { joined_since, sort: \"newest\" } lists who joined since a date " +
1965
+ "(read in the workspace's time zone), newest first. Reads need users.read:* " +
1964
1966
  "scope; edit-style mutations (invite/deactivate/reactivate/" +
1965
1967
  "sendPasswordReset/update) need users.write:*, and the destructive remove() " +
1966
1968
  "additionally needs users.delete:* (SC-902). The `invite` call accepts " +
@@ -2082,6 +2084,30 @@ const HOOKS = [
2082
2084
  requiredContextSlice: ["directory.users"],
2083
2085
  scopes: ["users.activity:*"],
2084
2086
  },
2087
+ // sc-11066 — who was seen in a window, behind users.activity.
2088
+ {
2089
+ name: "useActiveUsers",
2090
+ signature: "useActiveUsers({ within, group_id, limit, offset } | null)",
2091
+ optionalArgs: ["within", "group_id", "limit", "offset"],
2092
+ description:
2093
+ "One page of users seen within `within`: \"online\" (default; the app " +
2094
+ "open in the last two minutes), \"day\" (since midnight in the " +
2095
+ "workspace's time zone), \"week\" or \"month\" (the last 7 / 30 days), " +
2096
+ "most recently seen first: `users` = [{ id, name, last_active_at, " +
2097
+ "last_platform }], `total` for paging. Use it to show WHO is behind an " +
2098
+ "online or active figure. Same gate and errors as useUserActivity; null " +
2099
+ "reads nothing.",
2100
+ returnShape: {
2101
+ users: "Array<{ id, name, last_active_at, last_platform }>",
2102
+ total: "number | null",
2103
+ loading: "boolean",
2104
+ fetching: "boolean",
2105
+ error: "DirectoryError | null",
2106
+ refetch: "() => Promise<void>",
2107
+ },
2108
+ requiredContextSlice: ["directory.users"],
2109
+ scopes: ["users.activity:*"],
2110
+ },
2085
2111
  {
2086
2112
  name: "useInactiveUsers",
2087
2113
  signature: "useInactiveUsers({ inactive_days, group_id, limit, offset } | null)",
@@ -2091,7 +2117,8 @@ const HOOKS = [
2091
2117
  "never-seen first, then least recently seen: `users` = [{ id, name, " +
2092
2118
  "last_active_at, last_reminded_at }] (last_active_at null = never seen " +
2093
2119
  "since recording began), `total` for paging. `remind(id, { title, body })` " +
2094
- "notifies one of them, records when (last_reminded_at) and re-reads. " +
2120
+ "notifies one of them, records when (last_reminded_at) and re-reads; " +
2121
+ "pass `{ refetch: false }` third to skip the re-read in a batch. " +
2095
2122
  "Same gate and errors as useUserActivity; null reads nothing.",
2096
2123
  returnShape: {
2097
2124
  users: "Array<{ id, name, last_active_at, last_reminded_at }>",
@@ -2100,7 +2127,7 @@ const HOOKS = [
2100
2127
  fetching: "boolean",
2101
2128
  error: "DirectoryError | null",
2102
2129
  refetch: "() => Promise<void>",
2103
- remind: "(id, { title, body }) => Promise<void>",
2130
+ remind: "(id, { title, body }, { refetch? }) => Promise<void>",
2104
2131
  },
2105
2132
  requiredContextSlice: ["directory.users"],
2106
2133
  scopes: ["users.activity:*"],
@@ -2920,6 +2947,13 @@ const PRIMITIVES = [
2920
2947
  rnComponent: null,
2921
2948
  docsUrl: null,
2922
2949
  },
2950
+ {
2951
+ name: "ConfirmDialog",
2952
+ description:
2953
+ 'Standard confirmation prompt, a preset of `<Modal>`. `<ConfirmDialog visible={!!pending} tone="destructive" title="Delete this order?" message="This cannot be undone." confirmLabel={t("delete", "Delete")} onCancel={() => setPending(null)} onConfirm={async () => { await remove(pending.id); setPending(null); }} />`. THE way to ask "are you sure?" before a delete or any other action that cannot be undone, so every confirmation in the app has the same wording slots, button order and colours. Never hand-build one from a `<Modal>` or an `<Overlay>` with your own two buttons, never arm a button to be pressed twice, and never use `Alert.alert` or `window.confirm`. Props: `visible` (boolean), `title` (string, the question), `message` (what confirming does; text or a node), `confirmLabel` and `cancelLabel` (default the app\'s translated "Confirm" and "Cancel" — name the action: "Delete", "Remove", "Send"), `tone` ("default" | "destructive"; destructive draws the confirm button from the theme\'s Destructive colours and opens with focus on Cancel), `busy` (boolean; disables both buttons, shows a `<Spinner>` and keeps the dialog open), `onConfirm` (return the promise of the action and the dialog stays busy until it settles, with a second press ignored; a rejection is NOT caught, so catch a failure yourself and report it), `onCancel` (fired by the Cancel button, the close button, the backdrop press, the Escape key on web and the Android back button — cancelling is always the safe default), `closeAccessibilityLabel` and `testID`. The dialog never closes itself: set `visible` false from `onCancel` and after the action. Its panel follows the theme (Design > Components > Dialogs) and its buttons the Buttons and Destructive settings — do not re-style it.',
2954
+ rnComponent: null,
2955
+ docsUrl: null,
2956
+ },
2923
2957
  // sc-8104 — the in-app emoji picker. Typed emoji always worked; what this
2924
2958
  // replaces is being forced out to the OS keyboard to find one.
2925
2959
  {
@@ -5624,7 +5658,16 @@ const CONTRACT = deepFreeze({
5624
5658
  // switch, so an existing `<Switch>` with no colour props is repainted:
5625
5659
  // its tracks follow the theme on both hosts and its "on" thumb is white
5626
5660
  // on the web. A caller's `trackColor` / `thumbColor` still win.
5627
- version: "1.171.0",
5661
+ // 1.172.0: additive (sc-10925) — the `ConfirmDialog` primitive: the standard
5662
+ // confirmation, a preset of `Modal` and `Button` (title, message, confirm
5663
+ // and cancel labels, `tone` "default" | "destructive", `busy`). No new
5664
+ // theme scope: the panel reads `modal`, the actions `button` and
5665
+ // `destructive`. `Button` forwards a ref to its pressable.
5666
+ // 1.173.0: additive (sc-11066) — `useActiveUsers(query)` lists who was
5667
+ // seen online / today / in 7 or 30 days through the new
5668
+ // `ctx.directory.users.active`; `useUsers` takes `joined_since` and
5669
+ // `sort: "newest"` for an admin's "who joined" list.
5670
+ version: "1.173.0",
5628
5671
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
5629
5672
  hooks: HOOKS,
5630
5673
  primitives: PRIMITIVES,